dsh-code 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/history.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Global input recall: persistent cross-session entries plus this process's
3
+ * submissions, with Codex `ChatComposerHistory` semantics — empty submissions
4
+ * are ignored, adjacent duplicates collapse, the recall space skips
5
+ * persistent entries that duplicate a local one (local wins), and Up/Down
6
+ * navigation is gated so interior cursor movement never hijacks the draft.
7
+ *
8
+ * @module @deepseek-ai/dsh-tui/history
9
+ */
10
+
11
+ /** Maximum entries retained in the persistent history file. */
12
+ export const HISTORY_MAX_ENTRIES = 500
13
+
14
+ /** Encode one entry for the history file (JSON keeps multi-line drafts intact). */
15
+ export function serializeHistoryEntry(text: string): string {
16
+ return JSON.stringify(text)
17
+ }
18
+
19
+ /**
20
+ * Parse a persisted history file (one JSON entry per line): invalid lines
21
+ * drop out, empty entries are ignored, adjacent duplicates collapse, and the
22
+ * result keeps only the newest `max` entries.
23
+ * @param raw - file content, empty for a missing file.
24
+ * @param max - entry cap.
25
+ * @returns persistent entries, oldest first.
26
+ */
27
+ export function parseHistoryFile(raw: string, max = HISTORY_MAX_ENTRIES): readonly string[] {
28
+ const kept: string[] = []
29
+ for (const line of raw.split('\n')) {
30
+ if (line === '') continue
31
+ let text: unknown
32
+ try {
33
+ text = JSON.parse(line)
34
+ } catch {
35
+ continue
36
+ }
37
+ if (typeof text !== 'string' || text === '') continue
38
+ if (kept.length > 0 && kept[kept.length - 1] === text) continue
39
+ kept.push(text)
40
+ }
41
+ return kept.slice(-max)
42
+ }
43
+
44
+ /**
45
+ * Append one entry to the persistent file content: JSON line, capped to the
46
+ * newest `max` entries with a trailing newline.
47
+ * @param current - existing file content.
48
+ * @param text - submission to persist.
49
+ * @param max - entry cap.
50
+ * @returns the new file content.
51
+ */
52
+ export function appendHistoryContent(current: string, text: string, max = HISTORY_MAX_ENTRIES): string {
53
+ const entries = [...parseHistoryFile(current, max), text].slice(-max)
54
+ return entries.map(serializeHistoryEntry).join('\n') + '\n'
55
+ }
56
+
57
+ /**
58
+ * Record one in-session submission: empty text is ignored and an adjacent
59
+ * duplicate collapses (Codex `record_local_submission` semantics).
60
+ * @param local - current in-session entries, oldest first.
61
+ * @param text - the submitted prompt.
62
+ * @returns the updated local list.
63
+ */
64
+ export function recordLocalEntry(local: readonly string[], text: string): readonly string[] {
65
+ if (text === '') return local
66
+ if (local.length > 0 && local[local.length - 1] === text) return local
67
+ return [...local, text]
68
+ }
69
+
70
+ /**
71
+ * Build the recall space, newest first: local entries, then persistent
72
+ * entries whose text is not duplicated locally (the local copy wins and the
73
+ * persistent twin is skipped — Codex's replay-seed dedup, applied to the
74
+ * whole local set).
75
+ * @param persistent - cross-session entries, oldest first.
76
+ * @param local - this process's submissions, oldest first.
77
+ * @returns recall entries, newest first.
78
+ */
79
+ export function recallEntries(persistent: readonly string[], local: readonly string[]): readonly string[] {
80
+ const localSet = new Set(local)
81
+ return [...persistent.filter(entry => !localSet.has(entry)), ...local].reverse()
82
+ }
83
+
84
+ /** Shell-style recall navigation over a fixed recall space. */
85
+ export interface RecallState {
86
+ /** Recall entries, newest first (frozen at navigation start). */
87
+ entries: readonly string[]
88
+ /** Current recall index; null when not browsing. */
89
+ index: number | null
90
+ /** Draft saved when browsing started; restored on Down past the newest. */
91
+ savedDraft: string
92
+ /** The recalled text currently in the composer (the boundary gate's anchor). */
93
+ lastRecalled: string | null
94
+ }
95
+
96
+ /** Fresh navigation state over one recall space. */
97
+ export function beginRecall(entries: readonly string[], draft: string): RecallState {
98
+ return { entries, index: null, savedDraft: draft, lastRecalled: null }
99
+ }
100
+
101
+ /** The outcome of one recall step. */
102
+ export interface RecallStep {
103
+ state: RecallState
104
+ /** The text to place in the composer; undefined means "no movement". */
105
+ entry: string | undefined
106
+ }
107
+
108
+ /**
109
+ * Move one entry older (Up, toward index +1 in the newest-first space). The
110
+ * first Up saves the current draft so Down past the newest can restore it
111
+ * (Claude-Code shell recall — the draft is never lost); the oldest entry
112
+ * stays put.
113
+ * @param state - current navigation state.
114
+ * @param draft - the composer text to preserve when browsing starts.
115
+ */
116
+ export function recallOlder(state: RecallState, draft: string): RecallStep {
117
+ if (state.index === null) {
118
+ const entry = state.entries[0]
119
+ if (entry === undefined) return { state, entry: undefined }
120
+ return { state: { ...state, index: 0, savedDraft: draft, lastRecalled: entry }, entry }
121
+ }
122
+ if (state.index >= state.entries.length - 1) return { state, entry: undefined }
123
+ const entry = state.entries[state.index + 1]
124
+ return { state: { ...state, index: state.index + 1, lastRecalled: entry }, entry }
125
+ }
126
+
127
+ /** Move one entry newer (Down, toward index 0); past the newest, browsing ends and the saved draft returns. */
128
+ export function recallNewer(state: RecallState): RecallStep {
129
+ if (state.index === null) return { state, entry: undefined }
130
+ const next = state.index - 1
131
+ if (next < 0) {
132
+ return { state: { ...state, index: null, lastRecalled: null }, entry: state.savedDraft }
133
+ }
134
+ const entry = state.entries[next]
135
+ return { state: { ...state, index: next, lastRecalled: entry }, entry }
136
+ }
package/src/index.ts CHANGED
@@ -11,15 +11,16 @@
11
11
 
12
12
  import { randomUUID } from 'node:crypto'
13
13
  import { readFileSync } from 'node:fs'
14
- import { writeFile as writeFileAsync } from 'node:fs/promises'
15
- import { basename, join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
16
+ import { basename, dirname, join } from 'node:path'
16
17
  import { createElement } from 'react'
17
18
  import type { Context } from '@deepseek-ai/cordis'
18
19
  import z from '@deepseek-ai/schemastery'
19
20
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
20
21
  import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
21
22
  import type {} from '@deepseek-ai/dsh-agent-default-model'
22
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
23
+ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
23
24
  import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
24
25
  import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
25
26
  // Type-only: carries the ctx.sessionTitle service merge for /title.
@@ -33,9 +34,11 @@ import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
33
34
  import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
34
35
  import { internals, type TuiMount } from './internals.ts'
35
36
  import { loadModelDirectory, type ModelRow } from './models.ts'
36
- import { createMentions, type MentionsApi } from './mentions.ts'
37
+ import { createMentions, type MentionCandidate, type MentionsApi } from './mentions.ts'
37
38
  import { mountQuestionProvider, type QuestionStore } from './questions.ts'
38
- import { createTranscriptStore } from './store.ts'
39
+ import { createTranscriptStore, type TranscriptStore } from './store.ts'
40
+ import { parseStatuslineItems } from './render/status.ts'
41
+ import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
39
42
  import { watchSkills, type SkillsView } from './skills.ts'
40
43
  import { toolArgumentsPreview } from './render/tool-preview.ts'
41
44
  import { buildExportMarkdown } from './render/export.ts'
@@ -185,11 +188,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
185
188
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
186
189
 
187
190
  const cwd = process.cwd()
188
- const target = await resolveTarget(startup, persistence, cwd)
189
191
  const defaults = defaultModel.currentSelection()
190
192
  const presets = agentPresetsFrom(ctx)
191
193
  if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
192
194
 
195
+ // A bare fresh launch stays transient: no Agent or session is composed, and
196
+ // nothing is persisted, until the user's first real input. Explicit flags
197
+ // (--resume/--continue/--session/--mode) keep the eager create/resume path.
198
+ const lazy = startup.kind === 'fresh' && startup.mode === undefined
199
+
193
200
  interface ActiveSession {
194
201
  handle: AgentHandle
195
202
  agent: Agent
@@ -257,30 +264,42 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
257
264
  }
258
265
  }
259
266
 
260
- let active = await prepare(target)
261
- let agent = active.agent
262
- let session = active.session
263
- let store = active.store
264
- let mentions = active.mentions
267
+ let active: ActiveSession | undefined
268
+ let agent: Agent | undefined
269
+ let session: Session | undefined
270
+ let store: TranscriptStore = createTranscriptStore()
271
+ let mentions: MentionsApi | undefined
272
+
273
+ if (!lazy) {
274
+ const target = await resolveTarget(startup, persistence, cwd)
275
+ const prepared = await prepare(target)
276
+ active = prepared
277
+ agent = prepared.agent
278
+ session = prepared.session
279
+ store = prepared.store
280
+ mentions = prepared.mentions
281
+ }
265
282
 
266
283
  // Seed the transcript from the full session log: constructor seeds never
267
- // fire on `session/event`, so a resumed session paints its history once,
268
- // here, before the first render.
284
+ // fire on `session/event`, so a resumed session paints its history once
285
+ // before the first render. The handler reads the current session/store, so
286
+ // the deferred first session of a bare launch is covered by the same feed.
269
287
  const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
270
- if (subject.id === session.id) store.apply(event)
288
+ if (session !== undefined && subject.id === session.id) store.apply(event)
271
289
  })
272
290
 
273
291
  const commands: CommandsView = watchCommands(ctx)
274
- commands.setAgent(agent)
292
+ if (agent !== undefined) commands.setAgent(agent)
275
293
 
276
294
  const skills: SkillsView = watchSkills(ctx)
277
- skills.setAgent(agent)
295
+ if (agent !== undefined) skills.setAgent(agent)
278
296
 
279
297
  // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
280
- // claimed, every other ask falls through to the fail-closed waterfall.
298
+ // claimed, every other ask falls through to the fail-closed waterfall. The
299
+ // owner predicate is empty until the first session exists.
281
300
  const approval: ApprovalStore = mountApprovalAnswerer(
282
301
  ctx,
283
- candidate => candidate.id === agent.id,
302
+ candidate => agent !== undefined && candidate.id === agent.id,
284
303
  request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
285
304
  )
286
305
 
@@ -293,6 +312,71 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
293
312
  // process side (unknown commands, switch confirmations, cancels).
294
313
  const bridge: AppBridge = { notify: () => {} }
295
314
 
315
+ // /statusline persistence: one user-level JSON file under the DSH home.
316
+ // Missing file means defaults; a corrupt file degrades to defaults with a
317
+ // surfaced warning (the customization is user-authored, never silent).
318
+ const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
319
+ let statuslineWarning: string | undefined
320
+ let statuslineItems: readonly string[] = []
321
+ try {
322
+ statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
323
+ } catch (error) {
324
+ statuslineItems = parseStatuslineItems(undefined)
325
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
326
+ statuslineWarning = error instanceof Error ? error.message : String(error)
327
+ }
328
+ }
329
+ const saveStatusline = (items: readonly string[]): void => {
330
+ statuslineItems = [...items]
331
+ // The config directory may not exist on a first save; create it before
332
+ // the write so a fresh install persists customizations.
333
+ void mkdir(dirname(statuslinePath), { recursive: true })
334
+ .then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
335
+ .catch((writeError: unknown) => {
336
+ bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
337
+ })
338
+ }
339
+
340
+ // Global input recall (Codex composer-history contract): one JSONL file
341
+ // under the DSH home. A missing file means an empty history; unreadable or
342
+ // corrupt content degrades to the valid lines it could parse, silently —
343
+ // recall is a convenience surface, never a gate.
344
+ const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
345
+ let inputHistory: readonly string[] = []
346
+ try {
347
+ inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
348
+ } catch {
349
+ inputHistory = []
350
+ }
351
+ const recordHistory = (text: string): void => {
352
+ if (text === '') return
353
+ inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
354
+ // A missing file on the first save is not an error: start from empty.
355
+ let current = ''
356
+ try {
357
+ current = readFileSync(historyPath, 'utf8')
358
+ } catch {
359
+ current = ''
360
+ }
361
+ void mkdir(dirname(historyPath), { recursive: true })
362
+ .then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
363
+ .catch((writeError: unknown) => {
364
+ bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
365
+ })
366
+ }
367
+
368
+ /** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
369
+ const cancelQueued = (messageId: string): void => {
370
+ if (agent === undefined) return
371
+ try {
372
+ if (agent.inbox.remove(MessageId(messageId))) {
373
+ bridge.notify('queued message cancelled')
374
+ }
375
+ } catch (error: unknown) {
376
+ bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
377
+ }
378
+ }
379
+
296
380
  // The mount handle lives in a box: quit closes over it, while the mount
297
381
  // itself is created after quit (the App element needs quit as a prop).
298
382
  const mountRef: { current?: TuiMount } = {}
@@ -303,13 +387,21 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
303
387
  switchQueue.cancel()
304
388
  off()
305
389
  mountRef.current?.unmount()
306
- void sessions.flush(session)
390
+ // A bare launch that exits before the first input has no session: exit
391
+ // cleanly without flushing or disposing anything.
392
+ const currentSession = session
393
+ const currentActive = active
394
+ if (currentSession === undefined || currentActive === undefined) {
395
+ io.exit(0)
396
+ return
397
+ }
398
+ void sessions.flush(currentSession)
307
399
  .catch((flushError: unknown) => {
308
400
  // The session log already carries every durable event; a failed flush
309
401
  // must not trap the user in a dead terminal, so report and still exit.
310
402
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
311
403
  })
312
- .then(() => active.handle.dispose())
404
+ .then(() => currentActive.handle.dispose())
313
405
  .catch((disposeError: unknown) => {
314
406
  internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
315
407
  })
@@ -318,6 +410,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
318
410
 
319
411
  /** Run one slash line through the command registry (closed namespace). */
320
412
  const runSlash = (line: string): void => {
413
+ const currentAgent = agent
414
+ if (currentAgent === undefined) return
321
415
  if (line.startsWith('/mode ')) {
322
416
  void switchModeAction(line.slice(6).trim())
323
417
  return
@@ -332,13 +426,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
332
426
  return
333
427
  }
334
428
  const controller = new AbortController()
335
- void Promise.resolve().then(() => registry.execute(agent, line, controller.signal)).then((execution) => {
429
+ void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
336
430
  if (execution === undefined) {
337
431
  // No command owns this line: send it verbatim so a user-invocable
338
432
  // skill gesture (`/skill-name`) reaches the host's tool-skill
339
433
  // pre-step injection — the web composer's same fall-through.
340
434
  try {
341
- agent.followup(createUserMessage({
435
+ currentAgent.followup(createUserMessage({
342
436
  content: [{ type: 'text', text: line }],
343
437
  source: { kind: 'user' },
344
438
  }))
@@ -351,10 +445,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
351
445
  })
352
446
  }
353
447
 
354
- /** Deliver one readable line to the agent, expanding session mentions first. */
355
- const send = (text: string, mode: 'followup' | 'steer'): void => {
356
- const line = text.trim()
357
- if (line === '') return
448
+ /** Deliver one trimmed line to the live session, expanding mentions first. */
449
+ const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
450
+ const currentAgent = agent!
451
+ const currentMentions = mentions!
358
452
  // The command registry is a closed namespace: slash lines run out of
359
453
  // band and never reach the model through this path (steering keeps the
360
454
  // registry out of the inbox, so slash lines steer as literal text).
@@ -362,9 +456,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
362
456
  runSlash(line)
363
457
  return
364
458
  }
365
- let parsed: ReturnType<typeof mentions.parse>
459
+ let parsed: ReturnType<MentionsApi['parse']>
366
460
  try {
367
- parsed = mentions.parse(line)
461
+ parsed = currentMentions.parse(line)
368
462
  } catch (error: unknown) {
369
463
  bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
370
464
  return
@@ -374,16 +468,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
374
468
  // the readable message (upstream README wiring: inject before the
375
469
  // followup/steer that wakes the driver).
376
470
  try {
377
- if (context !== undefined) agent.inject(context)
471
+ if (context !== undefined) currentAgent.inject(context)
378
472
  const message = createUserMessage({
379
473
  content: [{ type: 'text', text: readable }],
380
474
  source: { kind: 'user' },
381
475
  })
382
476
  if (mode === 'steer') {
383
- agent.steer(message)
384
- bridge.notify('steering queued the next step sees it')
477
+ // The queued message is visible as a pending transcript row (the
478
+ // web queue-mirror contract); no notice noise on the happy path.
479
+ currentAgent.steer(message)
385
480
  } else {
386
- agent.followup(message)
481
+ currentAgent.followup(message)
387
482
  }
388
483
  } catch (error: unknown) {
389
484
  bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
@@ -394,7 +489,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
394
489
  return
395
490
  }
396
491
  const controller = new AbortController()
397
- void mentions.prepare(parsed, controller.signal).then((prepared) => {
492
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
398
493
  deliver(prepared.text, prepared.additionalContext)
399
494
  }, (error: unknown) => {
400
495
  if (controller.signal.aborted) return
@@ -402,6 +497,61 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
402
497
  })
403
498
  }
404
499
 
500
+ // Deferred first-session creation for a bare launch: the session is composed
501
+ // only when the user submits real input (or /new), and every line that
502
+ // arrives during creation is delivered in order afterwards. A creation
503
+ // failure reports and clears the queue, leaving the transient state ready
504
+ // for the next attempt.
505
+ const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
506
+ let creating: Promise<void> | undefined
507
+ const ensureSession = (mode?: string): void => {
508
+ if (creating !== undefined) return
509
+ const attempt = (async () => {
510
+ const next = await prepare({
511
+ sessionId: `session-${randomUUID()}`,
512
+ resume: false,
513
+ ...(mode === undefined ? {} : { mode }),
514
+ })
515
+ if (quitting) {
516
+ void next.handle.dispose().catch(() => {})
517
+ return
518
+ }
519
+ active = next
520
+ agent = next.agent
521
+ session = next.session
522
+ store = next.store
523
+ mentions = next.mentions
524
+ commands.setAgent(agent)
525
+ skills.setAgent(agent)
526
+ // The App mounts with a placeholder key until the first input; the
527
+ // key-change remount below must start from a clean screen or the ghost
528
+ // static header stays visible above the new one (same source-backed
529
+ // clear the session-switch path performs).
530
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
531
+ renderCurrent()
532
+ const queued = pendingInputs.splice(0)
533
+ for (const item of queued) deliverLine(item.text, item.mode)
534
+ })().catch((error: unknown) => {
535
+ pendingInputs.length = 0
536
+ bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
537
+ }).finally(() => {
538
+ creating = undefined
539
+ })
540
+ creating = attempt
541
+ }
542
+
543
+ /** Deliver one readable line to the agent, expanding session mentions first. */
544
+ const send = (text: string, mode: 'followup' | 'steer'): void => {
545
+ const line = text.trim()
546
+ if (line === '') return
547
+ if (session === undefined) {
548
+ pendingInputs.push({ text: line, mode })
549
+ ensureSession()
550
+ return
551
+ }
552
+ deliverLine(line, mode)
553
+ }
554
+
405
555
  /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
406
556
  const dispatch = (text: string): void => {
407
557
  send(text, 'followup')
@@ -418,7 +568,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
418
568
 
419
569
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
420
570
  const interrupt = (): boolean => {
421
- if (agent.status !== 'running') return false
571
+ if (agent === undefined || agent.status !== 'running') return false
422
572
  try {
423
573
  agent.cancel({ kind: 'user' })
424
574
  bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
@@ -435,6 +585,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
435
585
  * custom knob state wraps to the first declared preset.
436
586
  */
437
587
  const cyclePermission = (): string => {
588
+ if (session === undefined) throw new Error('no session yet — submit a message to start')
438
589
  const service = ctx.get('permissionPresets') as
439
590
  | {
440
591
  names: readonly string[]
@@ -460,6 +611,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
460
611
 
461
612
  /** Apply one /model selection: takes effect from the next assembled step. */
462
613
  const selectModel = (row: ModelRow): string => {
614
+ if (active === undefined) throw new Error('no session yet — submit a message to start')
463
615
  active.selection.picked = { provider: row.provider, model: row.model }
464
616
  return `${row.provider}/${row.model}`
465
617
  }
@@ -470,6 +622,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
470
622
  * workspace; an absolute or cwd-relative argument overrides it.
471
623
  */
472
624
  const exportTranscript = async (argument: string): Promise<void> => {
625
+ if (session === undefined) {
626
+ bridge.notify('no session yet — submit a message to start', 'warning')
627
+ return
628
+ }
473
629
  const wanted = argument.trim()
474
630
  const sessionCwd = session.header.cwd ?? cwd
475
631
  const defaultName = `dsh-session-${session.id.slice(-8)}.md`
@@ -495,6 +651,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
495
651
  const renameTitle = (argument: string): string => {
496
652
  const title = argument.trim()
497
653
  if (title === '') return 'usage: /title <text>'
654
+ if (session === undefined) return 'no session yet — submit a message to start'
498
655
  const service = ctx.get('sessionTitle')
499
656
  if (service === undefined) return 'session titles are unavailable in this profile'
500
657
  try {
@@ -524,10 +681,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
524
681
 
525
682
  const switchModeAction = async (id: string): Promise<string> => {
526
683
  if (id === '') throw new Error('usage: /mode <preset>')
527
- const preset = await switchPreset(presets, agent, id)
528
- active.mode = preset.id
529
- commands.setAgent(agent)
530
- skills.setAgent(agent)
684
+ const currentAgent = agent
685
+ const currentActive = active
686
+ if (currentAgent === undefined || currentActive === undefined) {
687
+ throw new Error('no session yet — submit a message to start')
688
+ }
689
+ const preset = await switchPreset(presets, currentAgent, id)
690
+ currentActive.mode = preset.id
691
+ commands.setAgent(currentAgent)
692
+ skills.setAgent(currentAgent)
531
693
  renderCurrent()
532
694
  return preset.id
533
695
  }
@@ -549,16 +711,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
549
711
  renderCurrent()
550
712
  } catch (error: unknown) {
551
713
  active = previous
552
- agent = previous.agent
553
- session = previous.session
554
- store = previous.store
555
- mentions = previous.mentions
556
- commands.setAgent(agent)
557
- skills.setAgent(agent)
714
+ agent = previous?.agent
715
+ session = previous?.session
716
+ store = previous === undefined ? createTranscriptStore() : previous.store
717
+ mentions = previous?.mentions
718
+ if (agent !== undefined) commands.setAgent(agent)
719
+ if (agent !== undefined) skills.setAgent(agent)
558
720
  await next.handle.dispose()
559
721
  renderCurrent()
560
722
  throw error
561
723
  }
724
+ // No previous session (a bare launch switched straight into a resume):
725
+ // nothing to flush or dispose, so just confirm the activation.
726
+ if (previous === undefined) {
727
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
728
+ return
729
+ }
562
730
  let cleanupWarning: string | undefined
563
731
  try {
564
732
  await sessions.flush(previous.session)
@@ -582,11 +750,20 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
582
750
  )
583
751
 
584
752
  const requestSwitch = (request: PendingSwitch): void => {
753
+ if (session === undefined) {
754
+ // No session yet (a bare launch using /resume before any input): activate
755
+ // the target directly — there is no running turn to wait on and nothing
756
+ // to flush.
757
+ void activate(request.target).catch((error: unknown) => {
758
+ bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
759
+ })
760
+ return
761
+ }
585
762
  if (request.target.sessionId === session.id) {
586
763
  bridge.notify('that session is already active', 'warning')
587
764
  return
588
765
  }
589
- const outcome = switchQueue.request(agent, request)
766
+ const outcome = switchQueue.request(agent!, request)
590
767
  if (outcome === 'queued') {
591
768
  bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
592
769
  }
@@ -603,7 +780,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
603
780
  if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
604
781
  throw new Error('subagent conversations are read-only in /resume; resume a root session')
605
782
  }
606
- if (agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
783
+ if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
607
784
  throw new Error('that session is already live in another owner')
608
785
  }
609
786
  return matches[0]!.header.id
@@ -616,6 +793,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
616
793
  }
617
794
 
618
795
  const createSession = (mode?: string): void => {
796
+ // /new before any input is the first-session creation itself, not a switch.
797
+ if (session === undefined) {
798
+ ensureSession(mode)
799
+ return
800
+ }
619
801
  const nextCwd = session.header.cwd ?? cwd
620
802
  const id = `session-${randomUUID()}`
621
803
  requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
@@ -634,10 +816,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
634
816
  }
635
817
 
636
818
  const appElement = (): ReturnType<typeof createElement> => {
637
- const sessionCwd = session.header.cwd ?? cwd
819
+ // A bare launch mounts with placeholder facts until the first input
820
+ // composes a real session: empty session id/mode, the deployment default
821
+ // model, and the working directory's basename. `status.ts` drops empty
822
+ // mode/sessionId, so the bar renders only the identity it actually has.
823
+ const sessionCwd = session?.header.cwd ?? cwd
638
824
  const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
639
825
  return createElement(App, {
640
- key: session.id,
826
+ key: session?.id ?? 'pending',
641
827
  store,
642
828
  approval,
643
829
  questions,
@@ -647,15 +833,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
647
833
  cwd: basename(sessionCwd),
648
834
  workspaceRoot: sessionCwd,
649
835
  branch: gitBranch(sessionCwd),
650
- sessionId: session.id.slice(-8),
651
- resumed: active.resumed,
652
- mode: active.mode,
836
+ sessionId: session === undefined ? '' : session.id.slice(-8),
837
+ resumed: active?.resumed ?? false,
838
+ mode: active?.mode ?? '',
653
839
  dispatch,
654
840
  steer,
655
841
  interrupt,
656
842
  quit,
657
843
  loadModels: () => loadModelDirectory(ctx),
658
- loadMentions: mentions.candidates,
844
+ loadMentions: mentions === undefined
845
+ ? () => Promise.resolve<readonly MentionCandidate[]>([])
846
+ : mentions.candidates,
659
847
  cyclePermission,
660
848
  selectModel,
661
849
  exportTranscript,
@@ -668,6 +856,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
668
856
  switchSession,
669
857
  cancelSessionSwitch,
670
858
  loadPlugins: () => listPluginRows(ctx),
859
+ statusline: statuslineItems,
860
+ saveStatusline,
861
+ history: inputHistory,
862
+ recordHistory,
863
+ cancelQueued,
671
864
  onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
672
865
  })
673
866
  }
@@ -677,6 +870,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
677
870
  }
678
871
 
679
872
  mountRef.current = io.mount(appElement())
873
+
874
+ // A corrupt statusline config must not vanish silently: surface it once
875
+ // the notice channel is live, after the first frame settles.
876
+ if (statuslineWarning !== undefined) {
877
+ setTimeout(() => {
878
+ bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
879
+ }, 50)
880
+ }
680
881
  }
681
882
 
682
883
  /**