11import { execFile } from 'child_process'
22import { existsSync } from 'fs'
3+ import { readFile , readdir } from 'fs/promises'
4+ import { homedir } from 'os'
5+ import { join } from 'path'
36
47export type PaneStatus = 'idle' | 'busy' | 'waiting'
58
@@ -18,6 +21,185 @@ export interface TmuxPane {
1821 prompt : string
1922}
2023
24+ // Strip ANSI escape sequences from captured pane content.
25+ // capture-pane -p normally strips them, but FLICK (alternate screen) mode
26+ // can leave CSI / OSC remnants in certain tmux versions.
27+ const ESC = String . fromCharCode ( 0x1b )
28+ const BEL = String . fromCharCode ( 0x07 )
29+ const RE_CSI = new RegExp ( ESC + '\\[[0-9;]*[A-Za-z]' , 'g' )
30+ const RE_OSC = new RegExp ( ESC + '\\][^' + BEL + ESC + ']*(?:' + BEL + '|' + ESC + '\\\\)' , 'g' )
31+ const RE_CHARSET = new RegExp ( ESC + '[()][AB012]' , 'g' )
32+ const RE_MODE = new RegExp ( ESC + '[>=]' , 'g' )
33+
34+ function stripAnsi ( text : string ) : string {
35+ return text . replace ( RE_CSI , '' ) . replace ( RE_OSC , '' ) . replace ( RE_CHARSET , '' ) . replace ( RE_MODE , '' )
36+ }
37+
38+ // Detect if a pane is currently showing the alternate screen buffer
39+ // (i.e. Claude Code is running in FLICK / NO_FLICKER mode).
40+ async function isAlternateScreen ( target : string ) : Promise < boolean > {
41+ try {
42+ const result = await run ( [ 'display-message' , '-t' , target , '-p' , '#{alternate_on}' ] )
43+ return result . trim ( ) === '1'
44+ } catch {
45+ return false
46+ }
47+ }
48+
49+ export interface ChatMessage {
50+ role : 'user' | 'assistant'
51+ text : string
52+ timestamp : string
53+ }
54+
55+ const TOOL_ICONS : Record < string , string > = {
56+ Read : '\u{1F4C4}' ,
57+ Edit : '\u{270F}\u{FE0F}' ,
58+ Write : '\u{1F4DD}' ,
59+ Bash : '\u{1F4BB}' ,
60+ Grep : '\u{1F50D}' ,
61+ Glob : '\u{1F4C2}' ,
62+ Agent : '\u{1F916}' ,
63+ WebFetch : '\u{1F310}' ,
64+ WebSearch : '\u{1F50E}' ,
65+ TodoWrite : '\u{1F4CB}'
66+ }
67+
68+ function encodeCwd ( cwd : string ) : string {
69+ return cwd . replace ( / [ / . ] / g, '-' )
70+ }
71+
72+ async function findSessionJsonl ( target : string ) : Promise < string | null > {
73+ try {
74+ const info = await run ( [
75+ 'display-message' ,
76+ '-t' ,
77+ target ,
78+ '-p' ,
79+ '#{pane_pid}|#{pane_current_path}'
80+ ] )
81+ const [ pid , cwd ] = info . trim ( ) . split ( '|' )
82+
83+ const claudeDir = join ( homedir ( ) , '.claude' )
84+ const sessionsDir = join ( claudeDir , 'sessions' )
85+
86+ // Try direct PID match
87+ try {
88+ const data = JSON . parse ( await readFile ( join ( sessionsDir , `${ pid } .json` ) , 'utf-8' ) )
89+ const jsonlPath = join ( claudeDir , 'projects' , encodeCwd ( data . cwd ) , `${ data . sessionId } .jsonl` )
90+ if ( existsSync ( jsonlPath ) ) return jsonlPath
91+ } catch {
92+ // PID doesn't match directly
93+ }
94+
95+ // Scan session files for matching CWD, pick most recent
96+ try {
97+ const files = await readdir ( sessionsDir )
98+ let best : { path : string ; startedAt : number } | null = null
99+
100+ for ( const file of files ) {
101+ if ( ! file . endsWith ( '.json' ) ) continue
102+ try {
103+ const data = JSON . parse ( await readFile ( join ( sessionsDir , file ) , 'utf-8' ) )
104+ if ( data . cwd === cwd ) {
105+ const jsonlPath = join (
106+ claudeDir ,
107+ 'projects' ,
108+ encodeCwd ( data . cwd ) ,
109+ `${ data . sessionId } .jsonl`
110+ )
111+ if ( existsSync ( jsonlPath ) && ( ! best || data . startedAt > best . startedAt ) ) {
112+ best = { path : jsonlPath , startedAt : data . startedAt }
113+ }
114+ }
115+ } catch {
116+ /* skip */
117+ }
118+ }
119+ return best ?. path ?? null
120+ } catch {
121+ return null
122+ }
123+ } catch {
124+ return null
125+ }
126+ }
127+
128+ function formatToolUse ( block : { name ?: string ; input ?: Record < string , string > } ) : string {
129+ const name = block . name ?? 'Tool'
130+ const icon = TOOL_ICONS [ name ] ?? '\u{1F527}'
131+ const input = block . input ?? { }
132+
133+ if ( ( name === 'Read' || name === 'Edit' || name === 'Write' ) && input . file_path ) {
134+ return `${ icon } ${ name } ${ input . file_path . split ( '/' ) . pop ( ) } `
135+ }
136+ if ( name === 'Bash' && input . command ) {
137+ return `${ icon } ${ input . command . slice ( 0 , 60 ) } `
138+ }
139+ if ( name === 'Grep' && input . pattern ) {
140+ return `${ icon } Grep "${ input . pattern . slice ( 0 , 40 ) } "`
141+ }
142+ return `${ icon } ${ name } `
143+ }
144+
145+ export async function getConversationLog ( target : string ) : Promise < ChatMessage [ ] > {
146+ const jsonlPath = await findSessionJsonl ( target )
147+ if ( ! jsonlPath ) return [ ]
148+
149+ try {
150+ const raw = await readFile ( jsonlPath , 'utf-8' )
151+ const messages : ChatMessage [ ] = [ ]
152+
153+ for ( const line of raw . split ( '\n' ) ) {
154+ if ( ! line . trim ( ) ) continue
155+ try {
156+ const record = JSON . parse ( line )
157+
158+ if ( record . type === 'user' && record . message ?. role === 'user' ) {
159+ const text =
160+ typeof record . message . content === 'string' ? record . message . content . trim ( ) : ''
161+ if ( text ) {
162+ messages . push ( { role : 'user' , text, timestamp : record . timestamp ?? '' } )
163+ }
164+ } else if ( record . type === 'assistant' && record . message ?. role === 'assistant' ) {
165+ const blocks = record . message . content
166+ if ( ! Array . isArray ( blocks ) ) continue
167+
168+ const parts : string [ ] = [ ]
169+ for ( const block of blocks ) {
170+ if ( block . type === 'text' && block . text ?. trim ( ) ) {
171+ parts . push ( block . text . trim ( ) )
172+ } else if ( block . type === 'tool_use' ) {
173+ parts . push ( formatToolUse ( block ) )
174+ }
175+ }
176+
177+ if ( parts . length > 0 ) {
178+ const last = messages [ messages . length - 1 ]
179+ if ( last ?. role === 'assistant' ) {
180+ // Merge consecutive assistant messages (same turn)
181+ last . text += '\n' + parts . join ( '\n' )
182+ last . timestamp = record . timestamp ?? last . timestamp
183+ } else {
184+ messages . push ( {
185+ role : 'assistant' ,
186+ text : parts . join ( '\n' ) ,
187+ timestamp : record . timestamp ?? ''
188+ } )
189+ }
190+ }
191+ }
192+ } catch {
193+ /* skip malformed lines */
194+ }
195+ }
196+
197+ return messages
198+ } catch {
199+ return [ ]
200+ }
201+ }
202+
21203const TMUX_PATHS = [ '/opt/homebrew/bin/tmux' , '/usr/local/bin/tmux' , '/usr/bin/tmux' ]
22204const GIT_PATHS = [ '/opt/homebrew/bin/git' , '/usr/local/bin/git' , '/usr/bin/git' ]
23205
@@ -121,7 +303,8 @@ const WAITING_PATTERNS = [
121303
122304async function capturePaneContent ( target : string ) : Promise < string > {
123305 try {
124- return await run ( [ 'capture-pane' , '-t' , target , '-p' ] )
306+ const output = await run ( [ 'capture-pane' , '-t' , target , '-p' ] )
307+ return stripAnsi ( output )
125308 } catch {
126309 return ''
127310 }
@@ -608,14 +791,53 @@ export async function killPane(target: string): Promise<{ success: boolean; erro
608791}
609792
610793function trimCliFooter ( output : string ) : string {
611- return output
794+ const lines = output . split ( '\n' )
795+
796+ // Strip trailing empty lines
797+ while ( lines . length > 0 && lines [ lines . length - 1 ] . trim ( ) === '' ) {
798+ lines . pop ( )
799+ }
800+
801+ // Strip CLI footer lines from the bottom: separator, session/model info,
802+ // prompt cursor, mode indicator, token/cost counters (FLICK mode).
803+ while ( lines . length > 0 ) {
804+ const last = lines [ lines . length - 1 ]
805+ if (
806+ / ─ { 5 , } / . test ( last ) ||
807+ / ^ \s * ( S e s s i o n | M o d e l ) \b / . test ( last ) ||
808+ / ^ \s * ❯ \s * $ / . test ( last ) ||
809+ / \b ( p l a n | c o m p a c t ) m o d e \b / . test ( last ) ||
810+ // FLICK mode renders token/cost counters and message counts in the footer
811+ / \d + \s * t o k e n s ? \b / i. test ( last ) ||
812+ / \$ [ \d . ] + \s * ( c o s t | s p e n t ) / i. test ( last ) ||
813+ // Keybinding hints that appear at the bottom of FLICK TUI
814+ / ^ \s * ( C t r l | E s c | E n t e r ) \b .* \b ( s e n d | c a n c e l | s u b m i t | m e n u ) \b / i. test ( last ) ||
815+ // Empty input area indicator
816+ / ^ \s * > \s * $ / . test ( last )
817+ ) {
818+ lines . pop ( )
819+ while ( lines . length > 0 && lines [ lines . length - 1 ] . trim ( ) === '' ) {
820+ lines . pop ( )
821+ }
822+ } else {
823+ break
824+ }
825+ }
826+
827+ return lines . join ( '\n' )
612828}
613829
614830export async function capturePane ( target : string ) : Promise < string > {
615831 if ( ! TARGET_PATTERN . test ( target ) ) return ''
616832 try {
617- const output = await run ( [ 'capture-pane' , '-t' , target , '-p' , '-S' , '-500' ] )
618- return trimCliFooter ( output )
833+ const altScreen = await isAlternateScreen ( target )
834+ // Alternate screen (FLICK / NO_FLICKER mode) has no scrollback history,
835+ // so -S -500 is useless. Just capture the current visible screen.
836+ const args = altScreen
837+ ? [ 'capture-pane' , '-t' , target , '-p' ]
838+ : [ 'capture-pane' , '-t' , target , '-p' , '-S' , '-500' ]
839+ const output = await run ( args )
840+ return trimCliFooter ( stripAnsi ( output ) )
619841 } catch {
620842 return ''
621843 }
@@ -663,5 +885,6 @@ export async function ensureShellPane(
663885export const _testInternals = {
664886 parseChoices,
665887 detectStatusClaude,
666- trimCliFooter
888+ trimCliFooter,
889+ stripAnsi
667890}
0 commit comments