fix(daemon): surface palette command failures as notices
The palette dispatch error branch only logged failed commands, so a failed new-tab/split/etc. spawn was silent to the user. Route every non-nil paletteExec error through d.reportError so it lands in notice history and shows a toast; unclassified errors fall back to the NoticeInternal catch-all until Task 8 wraps specific spawn failures.
- commit
- a4b2aa9c8f5c38662e54cb469bf5c763a2314ecb
- author
- brice · · 2026-07-20 17:38 UTC
- parent
- e6ae2e5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +24 -0
M internal/usecase/daemon/palette.go +1 -0
| @@ -305,6 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 305 | 305 | } | |
| 306 | 306 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil { | |
| 307 | 307 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |
| 308 | + | d.reportError(sess, err) | |
| 308 | 309 | } else { | |
| 309 | 310 | d.recordPaletteUse(cmd.Code) | |
| 310 | 311 | } | |
| @@ -305,6 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 305 | } | 305 | } | ||
| 306 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil { | 306 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil { | ||
| 307 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | 307 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | ||
| 308 | + | d.reportError(sess, err) | |||
| 308 | } else { | 309 | } else { | ||
| 309 | d.recordPaletteUse(cmd.Code) | 310 | d.recordPaletteUse(cmd.Code) | ||
| 310 | } | 311 | } | ||
M internal/usecase/daemon/palette_test.go +23 -0
| @@ -119,6 +119,29 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 119 | 119 | awaitFrame(t, sends, ports.MsgOutput) | |
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | + | // TestPaletteCommandFailureSurfacesAsNotice drives a palette command whose Run | |
| 123 | + | // target fails and asserts the failure reaches the user as a notice instead of | |
| 124 | + | // only a log line. Task 8 will wrap the new-tab spawn failure as a | |
| 125 | + | // domain.UserError with NoticeTabSpawn; until then it flows through | |
| 126 | + | // unclassified and lands on the NoticeInternal catch-all. | |
| 127 | + | func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | |
| 128 | + | p, release := newBlockingPTY(t) | |
| 129 | + | defer release() | |
| 130 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 131 | + | ptys := portsmocks.NewMockPTYFactory(t) | |
| 132 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once() | |
| 133 | + | d.ptys = ptys | |
| 134 | + | ||
| 135 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 136 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 137 | + | d.handleInput(sess, ac, []byte("CNT\r")) | |
| 138 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 139 | + | ||
| 140 | + | history := d.notices.history() | |
| 141 | + | require.NotEmpty(t, history, "failed palette command must record a notice") | |
| 142 | + | require.Equal(t, domain.NoticeInternal, history[0].Code) | |
| 143 | + | } | |
| 144 | + | ||
| 122 | 145 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |
| 123 | 146 | p, release := newBlockingPTY(t) | |
| 124 | 147 | defer release() | |
| @@ -119,6 +119,29 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 119 | awaitFrame(t, sends, ports.MsgOutput) | 119 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | + | // TestPaletteCommandFailureSurfacesAsNotice drives a palette command whose Run | |||
| 123 | + | // target fails and asserts the failure reaches the user as a notice instead of | |||
| 124 | + | // only a log line. Task 8 will wrap the new-tab spawn failure as a | |||
| 125 | + | // domain.UserError with NoticeTabSpawn; until then it flows through | |||
| 126 | + | // unclassified and lands on the NoticeInternal catch-all. | |||
| 127 | + | func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) { | |||
| 128 | + | p, release := newBlockingPTY(t) | |||
| 129 | + | defer release() | |||
| 130 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 131 | + | ptys := portsmocks.NewMockPTYFactory(t) | |||
| 132 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once() | |||
| 133 | + | d.ptys = ptys | |||
| 134 | + | ||||
| 135 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 136 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 137 | + | d.handleInput(sess, ac, []byte("CNT\r")) | |||
| 138 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 139 | + | ||||
| 140 | + | history := d.notices.history() | |||
| 141 | + | require.NotEmpty(t, history, "failed palette command must record a notice") | |||
| 142 | + | require.Equal(t, domain.NoticeInternal, history[0].Code) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 122 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | 145 | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | ||
| 123 | p, release := newBlockingPTY(t) | 146 | p, release := newBlockingPTY(t) | ||
| 124 | defer release() | 147 | defer release() | ||