1313 */
1414
1515import type { ExtensionAPI , ExtensionContext } from "@earendil-works/pi-coding-agent" ;
16+ import { DynamicBorder } from "@earendil-works/pi-coding-agent" ;
17+ import {
18+ Container ,
19+ type SelectItem ,
20+ SelectList ,
21+ Text ,
22+ } from "@earendil-works/pi-tui" ;
1623import { createState , resetState , type AgenticodingState } from "./state.js" ;
1724import { CONTEXT_PRIMER } from "./system-prompt.js" ;
1825import { buildNudge , registerWatchdog } from "./watchdog.js" ;
@@ -22,40 +29,12 @@ import { registerHandoffTool } from "./handoff/tool.js";
2229import { registerHandoffCommand } from "./handoff/command.js" ;
2330import { registerHandoffCompaction } from "./handoff/compact.js" ;
2431import { registerSpawnTool } from "./spawn/index.js" ;
25-
26- /** Build a status bar preview from ledger entries. */
27- function formatLedgerPreview ( state : AgenticodingState ) : string {
28- const names = Array . from ( state . ledger . keys ( ) ) . sort ( ) ;
29- if ( names . length === 0 ) return "(empty)" ;
30- return names
31- . map ( ( name ) => {
32- const content = state . ledger . get ( name ) ! ;
33- const firstLine = ( content . split ( "\n" ) [ 0 ] ?? "" ) . slice ( 0 , 60 ) ;
34- return `${ name } : ${ firstLine } ` ;
35- } )
36- . join ( "\n" ) ;
37- }
38-
39- /** Update TUI indicators: context usage + ledger count. */
40- function updateIndicators ( ctx : ExtensionContext , state : AgenticodingState ) : void {
41- if ( ! ctx . hasUI ) return ;
42-
43- const theme = ctx . ui . theme ;
44-
45- // Context usage
46- const usage = ctx . getContextUsage ( ) ;
47- if ( usage && usage . percent !== null ) {
48- const pct = Math . round ( usage . percent ) ;
49- const tone = pct >= 70 ? "error" : pct >= 50 ? "warning" : pct >= 30 ? "accent" : "dim" ;
50- ctx . ui . setStatus ( "agenticoding-ctx" , theme . fg ( "dim" , "ctx " ) + theme . fg ( tone , `${ pct } %` ) ) ;
51- } else {
52- ctx . ui . setStatus ( "agenticoding-ctx" , theme . fg ( "dim" , "ctx --%" ) ) ;
53- }
54-
55- // Ledger count
56- const count = state . ledger . size ;
57- ctx . ui . setStatus ( "agenticoding-ledger" , count > 0 ? `\u{1F4D2} ${ count } ` : "" ) ;
58- }
32+ import {
33+ STATUS_KEY_HANDOFF ,
34+ WIDGET_KEY_WARNING ,
35+ updateIndicators ,
36+ } from "./tui.js" ;
37+ import { formatEntryPreview } from "./ledger/store.js" ;
5938
6039export default function ( pi : ExtensionAPI ) : void {
6140 const state : AgenticodingState = createState ( ) ;
@@ -73,12 +52,93 @@ export default function (pi: ExtensionAPI): void {
7352 // ── Register commands ───────────────────────────────────────────
7453 registerHandoffCommand ( pi , state ) ;
7554
76- // ── /ledger command — show entries in overlay ─── ────────────────
55+ // ── /ledger command — interactive entry selector ────────────────
7756 pi . registerCommand ( "ledger" , {
78- description : "Show ledger entries with name, line count, and first-line preview" ,
57+ description : "Select a ledger entry to preview" ,
7958 handler : async ( _args , ctx ) => {
80- const preview = formatLedgerPreview ( state ) ;
81- ctx . ui . notify ( `Ledger (${ state . ledger . size } entries):\n${ preview } ` , "info" ) ;
59+ if ( ! ctx . hasUI ) {
60+ return ;
61+ }
62+
63+ await ctx . ui . custom < void > ( ( tui , theme , _kb , done ) => {
64+ const container = new Container ( ) ;
65+
66+ container . addChild (
67+ new DynamicBorder ( ( s : string ) => theme . fg ( "accent" , s ) ) ,
68+ ) ;
69+ container . addChild (
70+ new Text ( theme . fg ( "accent" , theme . bold ( ` Ledger (${ state . ledger . size } entries) ` ) ) , 1 , 0 ) ,
71+ ) ;
72+
73+ const entries = Array . from ( state . ledger . entries ( ) ) . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) ) ;
74+ let selectList : SelectList | undefined ;
75+ let finished = false ;
76+
77+ if ( entries . length === 0 ) {
78+ container . addChild (
79+ new Text ( theme . fg ( "dim" , " (empty) — use ledger_add to create entries" ) , 1 , 0 ) ,
80+ ) ;
81+ } else {
82+ const items : SelectItem [ ] = entries . map ( ( [ name , content ] ) => ( {
83+ value : name ,
84+ label : name ,
85+ description : formatEntryPreview ( content ) ,
86+ } ) ) ;
87+
88+ selectList = new SelectList ( items , Math . min ( items . length , 10 ) , {
89+ selectedPrefix : ( t ) => theme . fg ( "accent" , t ) ,
90+ selectedText : ( t ) => theme . fg ( "accent" , t ) ,
91+ description : ( t ) => theme . fg ( "muted" , t ) ,
92+ scrollInfo : ( t ) => theme . fg ( "dim" , t ) ,
93+ noMatch : ( t ) => theme . fg ( "warning" , t ) ,
94+ } ) ;
95+ selectList . onSelect = ( { value } ) => {
96+ // Guard: selectList is set to undefined below, so this handler
97+ // cannot fire twice — no re-entrancy guard needed here.
98+ const body = state . ledger . get ( value ) ;
99+ if ( ! body ) { done ( ) ; return ; }
100+ // Switch to body view: show the selected entry body inline
101+ container . clear ( ) ;
102+ container . addChild ( new DynamicBorder ( ( s : string ) => theme . fg ( "accent" , s ) ) ) ;
103+ container . addChild ( new Text ( theme . fg ( "accent" , theme . bold ( ` ${ value } ` ) ) , 1 , 0 ) ) ;
104+ const truncated = body . length > 500 ? body . slice ( 0 , 500 ) + "\n..." : body ;
105+ container . addChild ( new Text ( theme . fg ( "toolOutput" , truncated ) , 1 , 0 ) ) ;
106+ container . addChild ( new Text ( theme . fg ( "dim" , " press any key to close " ) , 1 , 0 ) ) ;
107+ container . addChild ( new DynamicBorder ( ( s : string ) => theme . fg ( "accent" , s ) ) ) ;
108+ selectList = undefined ;
109+ tui . requestRender ( ) ;
110+ } ;
111+ selectList . onCancel = ( ) => {
112+ if ( finished ) return ;
113+ finished = true ;
114+ done ( ) ;
115+ } ;
116+ container . addChild ( selectList ) ;
117+ }
118+
119+ container . addChild (
120+ new Text ( theme . fg ( "dim" , entries . length === 0
121+ ? " esc close "
122+ : " \u2191\u2195 navigate \u2022 enter select \u2022 esc close " ) , 1 , 0 ) ,
123+ ) ;
124+ container . addChild (
125+ new DynamicBorder ( ( s : string ) => theme . fg ( "accent" , s ) ) ,
126+ ) ;
127+
128+ return {
129+ render : ( w ) => container . render ( w ) ,
130+ invalidate : ( ) => container . invalidate ( ) ,
131+ handleInput : ( data ) => {
132+ if ( finished ) return ;
133+ if ( ! selectList ) { finished = true ; done ( ) ; return ; }
134+ selectList . handleInput ?.( data ) ;
135+ // Conservative: always repaint after key input.
136+ // SelectList.handleInput returns void in the current API,
137+ // so we can't conditionally skip — the cost is negligible.
138+ tui . requestRender ( ) ;
139+ } ,
140+ } ;
141+ } ) ;
82142 } ,
83143 } ) ;
84144
@@ -138,12 +198,25 @@ export default function (pi: ExtensionAPI): void {
138198 pi . on ( "session_start" , async ( event , ctx : ExtensionContext ) => {
139199 if ( event . reason === "new" ) {
140200 resetState ( state ) ;
201+ // Clear any stale TUI indicators from the previous session
202+ if ( ctx . hasUI ) {
203+ ctx . ui . setStatus ( STATUS_KEY_HANDOFF , undefined ) ;
204+ ctx . ui . setWidget ( WIDGET_KEY_WARNING , undefined ) ;
205+ }
141206 }
142207 updateIndicators ( ctx , state ) ;
143208 } ) ;
144209
145210 // ── update TUI indicators after each turn ───────────────────────
146211 pi . on ( "turn_end" , async ( _event , ctx : ExtensionContext ) => {
212+ // Fallback: clear handoff indicator if the LLM completed a turn
213+ // without calling the handoff tool (ignored the direction)
214+ if ( state . pendingRequestedHandoff && ! state . pendingRequestedHandoff . toolCalled ) {
215+ state . pendingRequestedHandoff = null ;
216+ if ( ctx . hasUI ) {
217+ ctx . ui . setStatus ( STATUS_KEY_HANDOFF , undefined ) ;
218+ }
219+ }
147220 updateIndicators ( ctx , state ) ;
148221 } ) ;
149222}
0 commit comments