@@ -47,6 +47,7 @@ const (
4747 stepRunning
4848 stepDone
4949 stepFailed
50+ stepSkipped
5051)
5152
5253// extractionStepInfo tracks the state of a single extraction step.
@@ -125,6 +126,10 @@ type extractionLogState struct {
125126 previewRow int // row cursor within active tab
126127 previewCol int // column cursor within active tab
127128
129+ // LLM ping state: ping runs concurrently with earlier steps.
130+ llmPingDone bool // true once ping completed (success or fail)
131+ llmPingErr error // non-nil if LLM was unreachable
132+
128133 // Model picker: inline model selection before rerunning LLM step.
129134 modelPicker * modelCompleter // non-nil when picker is showing
130135 modelFilter string // current filter text for fuzzy matching
@@ -178,7 +183,7 @@ func (ex *extractionLogState) cursorStep() extractionStep {
178183// steps collapse their log content by default.
179184func (ex * extractionLogState ) stepDefaultExpanded (si extractionStep ) bool {
180185 info := ex .Steps [si ]
181- if info .Status == stepRunning || info .Status == stepFailed {
186+ if info .Status == stepRunning || info .Status == stepFailed || info . Status == stepSkipped {
182187 // Ext with tools: collapsed by default while running since
183188 // the parent header shows the combined percentage.
184189 if si == stepExtract && len (ex .acquireTools ) > 0 && info .Status == stepRunning {
@@ -198,16 +203,16 @@ func (ex *extractionLogState) stepExpanded(si extractionStep) bool {
198203 return ex .stepDefaultExpanded (si )
199204}
200205
201- // advanceCursor moves the cursor to the latest settled (done/failed) step.
202- // In manual mode (after user presses j/k) this is a no-op.
206+ // advanceCursor moves the cursor to the latest settled (done/failed/skipped)
207+ // step. In manual mode (after user presses j/k) this is a no-op.
203208func (ex * extractionLogState ) advanceCursor () {
204209 if ex .cursorManual {
205210 return
206211 }
207212 active := ex .activeSteps ()
208213 for i := len (active ) - 1 ; i >= 0 ; i -- {
209214 s := ex.Steps [active [i ]].Status
210- if s == stepDone || s == stepFailed {
215+ if s == stepDone || s == stepFailed || s == stepSkipped {
211216 ex .cursor = i
212217 ex .toolCursor = - 1
213218 return
@@ -237,6 +242,12 @@ type extractionLLMChunkMsg struct {
237242 Err error
238243}
239244
245+ // extractionLLMPingMsg delivers the result of a background LLM ping.
246+ type extractionLLMPingMsg struct {
247+ ID uint64
248+ Err error // nil = reachable, non-nil = unreachable
249+ }
250+
240251// --- Overlay lifecycle ---
241252
242253// startExtractionOverlay opens the extraction progress overlay and kicks off
@@ -336,6 +347,11 @@ func (m *Model) startExtractionOverlay(
336347 state .Steps [stepExtract ].Status = stepRunning
337348 state .Steps [stepExtract ].Started = time .Now ()
338349 cmd = asyncExtractCmd (ctx , state )
350+ // Ping LLM concurrently so we know before OCR finishes whether
351+ // the LLM endpoint is reachable.
352+ if needsLLM {
353+ return tea .Batch (cmd , m .llmPingCmd (state ), state .Spinner .Tick )
354+ }
339355 } else if needsLLM {
340356 state .Steps [stepLLM ].Status = stepRunning
341357 state .Steps [stepLLM ].Started = time .Now ()
@@ -464,6 +480,23 @@ func waitForExtractProgress(id uint64, ch <-chan extract.ExtractProgress) tea.Cm
464480 }, extractionProgressMsg {ID : id , Progress : extract.ExtractProgress {Done : true }})
465481}
466482
483+ // llmPingCmd fires a background ping to the LLM endpoint. The result is
484+ // delivered via extractionLLMPingMsg so the extraction can skip the LLM
485+ // step early if the server is unreachable.
486+ func (m * Model ) llmPingCmd (state * extractionLogState ) tea.Cmd {
487+ client := m .extractionLLMClient ()
488+ if client == nil {
489+ return nil
490+ }
491+ id := state .ID
492+ return func () tea.Msg {
493+ ctx , cancel := context .WithTimeout (context .Background (), llm .QuickOpTimeout )
494+ defer cancel ()
495+ err := client .Ping (ctx )
496+ return extractionLLMPingMsg {ID : id , Err : err }
497+ }
498+ }
499+
467500// llmExtractCmd starts LLM document analysis with streaming.
468501func (m * Model ) llmExtractCmd (ctx context.Context , ex * extractionLogState ) tea.Cmd {
469502 client := m .extractionLLMClient ()
@@ -560,14 +593,8 @@ func (m *Model) handleExtractionProgress(msg extractionProgressMsg) tea.Cmd {
560593 ex .HasError = true
561594 ex .advanceCursor ()
562595 // Extraction failed but LLM can still run on whatever text exists.
563- if ex .hasLLM {
564- client := m .extractionLLMClient ()
565- if client != nil {
566- ex .Steps [stepLLM ].Status = stepRunning
567- ex .Steps [stepLLM ].Started = time .Now ()
568- ex .Steps [stepLLM ].Detail = m .extractionModelLabel ()
569- return m .llmExtractCmd (ex .ctx , ex )
570- }
596+ if cmd := m .maybeStartLLMStep (ex ); cmd != nil {
597+ return cmd
571598 }
572599 ex .Done = true
573600 if m .isBgExtraction (ex ) {
@@ -624,15 +651,9 @@ func (m *Model) handleExtractionProgress(msg extractionProgressMsg) tea.Cmd {
624651 ex .extractedText = p .Text
625652 }
626653
627- // Advance to LLM step if configured.
628- if ex .hasLLM {
629- client := m .extractionLLMClient ()
630- if client != nil {
631- ex .Steps [stepLLM ].Status = stepRunning
632- ex .Steps [stepLLM ].Started = time .Now ()
633- ex .Steps [stepLLM ].Detail = m .extractionModelLabel ()
634- return m .llmExtractCmd (ex .ctx , ex )
635- }
654+ // Advance to LLM step if configured and reachable.
655+ if cmd := m .maybeStartLLMStep (ex ); cmd != nil {
656+ return cmd
636657 }
637658
638659 ex .Done = true
@@ -642,6 +663,55 @@ func (m *Model) handleExtractionProgress(msg extractionProgressMsg) tea.Cmd {
642663 return nil
643664}
644665
666+ // maybeStartLLMStep attempts to advance to the LLM step. If the concurrent
667+ // ping determined the LLM is unreachable, the step is marked skipped and nil
668+ // is returned. Otherwise it starts the LLM streaming command.
669+ func (m * Model ) maybeStartLLMStep (ex * extractionLogState ) tea.Cmd {
670+ if ! ex .hasLLM {
671+ return nil
672+ }
673+ // Already marked skipped by the ping handler.
674+ if ex .Steps [stepLLM ].Status == stepSkipped {
675+ return nil
676+ }
677+ client := m .extractionLLMClient ()
678+ if client == nil {
679+ return nil
680+ }
681+ ex .Steps [stepLLM ].Status = stepRunning
682+ ex .Steps [stepLLM ].Started = time .Now ()
683+ ex .Steps [stepLLM ].Detail = m .extractionModelLabel ()
684+ return m .llmExtractCmd (ex .ctx , ex )
685+ }
686+
687+ // handleExtractionLLMPing processes the background LLM ping result.
688+ func (m * Model ) handleExtractionLLMPing (msg extractionLLMPingMsg ) tea.Cmd {
689+ ex := m .findExtraction (msg .ID )
690+ if ex == nil {
691+ return nil
692+ }
693+ ex .llmPingDone = true
694+ ex .llmPingErr = msg .Err
695+
696+ if msg .Err != nil {
697+ // Mark LLM as skipped immediately so the strikethrough renders
698+ // in real time, even while earlier steps are still running.
699+ ex .Steps [stepLLM ].Status = stepSkipped
700+ ex .Steps [stepLLM ].Detail = m .extractionModelLabel ()
701+ ex .Steps [stepLLM ].Logs = append (ex .Steps [stepLLM ].Logs , msg .Err .Error ())
702+
703+ // If extraction already finished, the pipeline is done.
704+ if ex .Steps [stepExtract ].Status == stepDone || ex .Steps [stepExtract ].Status == stepFailed {
705+ ex .Done = true
706+ ex .advanceCursor ()
707+ if m .isBgExtraction (ex ) {
708+ m .setStatusInfo (fmt .Sprintf ("Extracted: %s (LLM skipped)" , ex .Filename ))
709+ }
710+ }
711+ }
712+ return nil
713+ }
714+
645715// handleExtractionLLMStarted stores the LLM stream channel and starts reading.
646716func (m * Model ) handleExtractionLLMStarted (msg extractionLLMStartedMsg ) tea.Cmd {
647717 ex := m .findExtraction (msg .ID )
@@ -870,8 +940,10 @@ func (m *Model) rerunLLMExtraction() tea.Cmd {
870940 ex .CancelFn = cancel
871941 }
872942
873- // Reset LLM state.
943+ // Reset LLM state (including any prior ping failure) .
874944 ex .llmAccum .Reset ()
945+ ex .llmPingDone = false
946+ ex .llmPingErr = nil
875947 ex .operations = nil
876948 ex .closeShadowDB ()
877949 ex .previewGroups = nil
@@ -1604,6 +1676,9 @@ func (m *Model) renderExtractionStep(
16041676 case stepFailed :
16051677 icon = m .styles .ExtFail ().Render ("xx" ) + " "
16061678 nameStyle = m .styles .ExtFailed ()
1679+ case stepSkipped :
1680+ icon = m .styles .ExtPending ().Render ("na" ) + " "
1681+ nameStyle = m .styles .ExtPending ()
16071682 }
16081683
16091684 hasTools := si == stepExtract && len (ex .acquireTools ) > 0
@@ -1670,7 +1745,9 @@ func (m *Model) renderExtractionStep(
16701745 hdr .WriteString (" " )
16711746 hdr .WriteString (hint .Render (fmt .Sprintf ("%*s" , cols .Elapsed , e )))
16721747 }
1673- if si == stepLLM && info .Status == stepDone && ex .Done && focused && ex .modelPicker == nil {
1748+ llmTerminal := info .Status == stepDone || info .Status == stepFailed ||
1749+ info .Status == stepSkipped
1750+ if si == stepLLM && llmTerminal && ex .Done && focused && ex .modelPicker == nil {
16741751 hdr .WriteString (" " )
16751752 hdr .WriteString (m .styles .ExtRerun ().Render ("r model" ))
16761753 }
@@ -1783,7 +1860,7 @@ func (m *Model) renderExtractionStep(
17831860 raw := strings .Join (info .Logs , "\n " )
17841861
17851862 var rendered string
1786- if si == stepLLM {
1863+ if si == stepLLM && info . Status != stepSkipped {
17871864 // Pretty-print JSON, then render as a fenced code block via glamour.
17881865 formatted := raw
17891866 var buf bytes.Buffer
@@ -1792,6 +1869,8 @@ func (m *Model) renderExtractionStep(
17921869 }
17931870 md := fmt .Sprintf ("```json\n %s\n ```" , formatted )
17941871 rendered = strings .TrimSpace (ex .renderMarkdown (md , logW ))
1872+ } else if info .Status == stepSkipped {
1873+ rendered = m .styles .ExtSkipLog ().Render (wordWrap (raw , logW ))
17951874 } else {
17961875 rendered = m .styles .HeaderHint ().Render (wordWrap (raw , logW ))
17971876 }
0 commit comments